Skip to content

feat(mgmt-agent): add node-health detection and labeling controller (AROSLSRE-1588) - #6284

Open
raelga wants to merge 14 commits into
Azure:mainfrom
raelga:raelga/aroslsre-1588-node-healer
Open

feat(mgmt-agent): add node-health detection and labeling controller (AROSLSRE-1588)#6284
raelga wants to merge 14 commits into
Azure:mainfrom
raelga:raelga/aroslsre-1588-node-healer

Conversation

@raelga

@raelga raelga commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Relates to AROSLSRE-1588

Implements the design in #6298 (docs/controllers/node-health.md).

What

Adds a node-health controller to mgmt-agent. It watches management-cluster Nodes, Pods, and kubelet Pod Events through shared informers and, when a SWIFT-v2 node is Ready but wedged, labels the node (node-health.aro-hcp.azure.com/status=wedged, plus detector/reason/observed-at annotations) so a separate mitigation controller can act on it. This controller does detection plus a label/unlabel flow only, it does not cordon, drain, taint, or delete nodes; all disruptive mitigation is a separate controller.

Detection is hard-coded and modular, not config-driven:

  • Each fault family is a Go detector over a shared toolkit: event-signature match, a sustained-storm floor, a per-pod dwell, and the load-bearing zero-successful-start discriminator that separates a hard wedge (VF gone) from a flap.
  • The reconcile core is a pure function decide(node, events, pods, now, observedSince, lastSuccessAt) with no API access, fed from a pod-by-node indexer and an event-by-node indexer keyed on Event.Source.Host, so it is exhaustively table-tested. Events are correlated to a specific Pod by InvolvedObject.UID, never by name, and are used only to classify failing pods, never counted.
  • The floor and dwell come from durable Pod state: a pod counts toward the floor only once its own PodReadyToStartContainers=False condition has held past the dwell, so one long-stuck pod plus a burst of brand-new ones does not fire. Success is a recorded per-node history of PodReadyToStartContainers=True transitions, so it survives Event and Pod garbage collection.
  • The one registered detector is the Azure SWIFT-v2 delegated-NIC teardown, where a node stays Ready but every pod sandbox fails with no such network interface / network is unreachable / mtpnc is not ready / DHCP-discover timeouts.
  • Scope is the AKS-managed label kubernetes.azure.com/podnetwork-swiftv2-enabled=true, not a node-name allow-list.

Runtime configuration is a single operational switch delivered via a watched ConfigMap (mgmt-agent-node-health, hot-reload): enabled, a hard off switch. Detection thresholds are deliberately hard-coded as constants in each detector's Go file, not configured, so the whole detector stays a pure, exhaustively testable unit. The controller ships disabled by default.

Why

The SWIFT-v2 delegated-NIC teardown leaves a node Ready while every new pod fails to get a sandbox, so the scheduler keeps placing work on a node that cannot run it. We hit this in production (australiaeast, uksouth) and mitigated it by hand. Labeling the node lets an out-of-band consumer drain or replace it automatically. Hard-coding the detection (instead of a config-driven engine) keeps the thresholds and the zero-success rule under code review and test, where they belong, and avoids a live production lever on a safety trigger.

Testing

  • go build ./..., go vet ./... clean on the mgmt-agent module.
  • go test ./pkg/controller/nodehealth/...: table-driven decide() cases (NotReady, no-detector, sub-floor, some-success flap, sustained wedge, per-pod dwell not met, one-old-plus-new-does-not-fire, event-UID correlation, recorded-success recovery), the recorded per-node success history (SuccessAt), plus config parse/validate and labeler apply/clear/idempotency.
  • controller_test.go: white-box controller tests covering enable-gated enqueue, per-node observation and success recording across pod add/update/delete, hot-enable observation reset, success pruning past the max window, the informer index funcs, and end-to-end syncHandler wedge/recovery. Package coverage 55.8%, detectors 78.1%.
  • Helm fixture regenerated (make update-helm-fixtures).
  • Live-validated on a personal dev cluster (not CSPR): clean startup and informer sync, config hot-reload, disabled=no-op, SWIFT-v2 scoping, end-to-end wedge labeling (label + detector/reason/observed-at annotations + NodeHealthLabeled Event), and recovery/unlabel.

Metrics

This PR adds a nodehealth Prometheus subsystem (all ALPHA), exposed on the existing mgmt-agent /metrics endpoint: nodehealth_detections_total{detector}, nodehealth_label_actions_total{action,result}, and nodehealth_wedged_nodes.

Before: no nodehealth_* series exist (the subsystem is new in this PR).

After (scraped from the leader mgmt-agent pod on the dev cluster, after the live wedge + recovery test):

# HELP nodehealth_detections_total [ALPHA] Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.
# TYPE nodehealth_detections_total counter
nodehealth_detections_total{detector="swift-vf-teardown"} 1
# HELP nodehealth_label_actions_total [ALPHA] Number of label/unlabel actions, by action and result.
# TYPE nodehealth_label_actions_total counter
nodehealth_label_actions_total{action="label",result="success"} 1
nodehealth_label_actions_total{action="unlabel",result="success"} 1
# HELP nodehealth_wedged_nodes [ALPHA] Number of nodes currently carrying the wedged health label, as observed by the node-health controller. Reported as 0 while the controller is disabled.
# TYPE nodehealth_wedged_nodes gauge
nodehealth_wedged_nodes 0

The counters recorded exactly one label and one unlabel over the synthetic wedge/recovery cycle; wedged_nodes is back to 0 after cleanup.

The scrape above is the before/after evidence for this change. There is no dashboard screenshot to add: the nodehealth subsystem is new here, nothing consumes it yet (no dashboard, no recording rule, no alert), and the controller ships disabled in every environment, so there is no panel that looks different before and after. The change that wires these series into a dashboard is the one that carries screenshots.

Copilot AI review requested due to automatic review settings July 27, 2026 14:41
@openshift-ci
openshift-ci Bot requested review from janboll and roivaz July 27, 2026 14:41
@openshift-ci

openshift-ci Bot commented Jul 27, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: raelga

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@raelga

raelga commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

/retitle WIP feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588)

@openshift-ci openshift-ci Bot changed the title feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) WIP feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) Jul 27, 2026
@raelga

raelga commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

/retitle WIP - DO NOT REVIEW YET - feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588)

@openshift-ci openshift-ci Bot changed the title WIP feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) WIP - DO NOT REVIEW YET - feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) Jul 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new mgmt-agent “node-healer” controller that watches kubelet Pod Events for configured failure signatures and, when thresholds/dwell are met for allow-listed nodes, performs a safety-gated remediation workflow (cordon → drain → delete) to trigger AKS reprovisioning. The controller is configured via a watched ConfigMap (hot-reload) and ships disabled + dry-run by default, with new Prometheus metrics and unit test coverage.

Changes:

  • Introduces the nodehealer controller package (config parsing/validation, signal tracking, evaluation, remediation, and metrics) plus unit tests.
  • Wires the controller into mgmt-agent leader election, adds Helm templates/values, default ConfigMap, and RBAC needed to cordon/drain/delete nodes.
  • Regenerates Helm golden fixture to include the new ConfigMap and deployment args.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
mgmt-agent/zz_fixture_TestHelmTemplate_dev_westus3_mgmt_1_mgmt_agent.yaml Updated Helm golden fixture to include node-healer ConfigMap, args, and RBAC deltas.
mgmt-agent/values.yaml Adds default node-healer chart values (disabled + dry-run default config).
mgmt-agent/pkg/controller/nodehealer/config.go Implements YAML config model, defaults, compilation (regex), and validation.
mgmt-agent/pkg/controller/nodehealer/config_test.go Unit tests for config parse/override/validation/allow-list behavior.
mgmt-agent/pkg/controller/nodehealer/consts.go Shared constants for patch fieldManager/annotations (and related controller constants).
mgmt-agent/pkg/controller/nodehealer/controller.go Core controller loop (queue, resync, evaluation, remediation orchestration, hot-reload).
mgmt-agent/pkg/controller/nodehealer/detector.go Node evaluation logic (safety gating + detector firing → remediation selection).
mgmt-agent/pkg/controller/nodehealer/detector_test.go Unit tests for evaluation guards and firing/remediation behavior.
mgmt-agent/pkg/controller/nodehealer/metrics.go Prometheus metrics definitions + registration helper.
mgmt-agent/pkg/controller/nodehealer/remediation.go Remediation engine (cordon/drain/delete, gating, events/metrics).
mgmt-agent/pkg/controller/nodehealer/remediation_test.go Unit tests for remediation actions (including dry-run and pod filtering).
mgmt-agent/pkg/controller/nodehealer/signals.go Event-informer signal ingestion + sliding-window tracking logic.
mgmt-agent/pkg/controller/nodehealer/signals_test.go Unit tests for tracker window/dwell/forget semantics.
mgmt-agent/deploy/templates/node-healer-config.yaml New Helm template to render the watched node-healer ConfigMap.
mgmt-agent/deploy/templates/deployment.yaml Adds node-healer configmap/key args to mgmt-agent deployment.
mgmt-agent/deploy/templates/clusterrole.yaml Expands RBAC to support node patch/delete and pod delete for drain.
mgmt-agent/cmd/options.go Wires node-healer informers, configmap watcher, recorder, and controller startup under leader election.
mgmt-agent/cmd/cmd.go Updates CLI help text documenting the new controller.
Comments suppressed due to low confidence (1)

mgmt-agent/pkg/controller/nodehealer/remediation.go:201

  • The remediation parameter is unused in drain(), which is likely to be reported by the enabled golangci-lint unused linter. Use it or rename it to _.
func (r *Remediator) drain(ctx context.Context, name string, dryRun bool, remediation string) error {

Comment thread mgmt-agent/pkg/controller/nodehealer/consts.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/remediation.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/controller.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/controller.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/remediation.go Outdated
Comment thread mgmt-agent/cmd/options.go Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 17:11
@raelga
raelga force-pushed the raelga/aroslsre-1588-node-healer branch from 37e547e to 9843b89 Compare July 27, 2026 17:11
@raelga raelga changed the title WIP - DO NOT REVIEW YET - feat(mgmt-agent): add generic node-healer controller (AROSLSRE-1588) WIP - DO NOT REVIEW YET - feat(mgmt-agent): add node-healer detection + labelling controller (AROSLSRE-1588) Jul 27, 2026
@raelga
raelga force-pushed the raelga/aroslsre-1588-node-healer branch from 9843b89 to 6e81bd9 Compare July 27, 2026 17:15
@raelga

raelga commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Force-pushed (WIP): rescoped this PR to phase 1 — detection + labelling only (per review feedback to ship the smallest useful increment first). Removed the cordon/drain/delete remediation path, the Confirmer/Scaler hooks, and the concurrency/cooldown/pre-scale safety machinery; the controller now sets/clears a node-healer.aro-hcp.azure.com/status=wedged label and reconciles it both ways. RBAC shrank to nodes get/list/watch/update/patch + events. Helm fixture regenerated.

Second push in this batch addresses the Copilot review: removed a dead const, made SetConfig update tracker retention on hot-reload, and shut the event broadcaster down on ctx.Done(). Still WIP — do not review yet.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

mgmt-agent/pkg/controller/nodehealer/controller.go:125

  • Tracker retention is computed only once from the initial config (Default) and never updated on hot-reload. If a ConfigMap reload increases a detector window or dwell, the tracker will keep pruning history too aggressively and firings/dwell timing will be incorrect until enough new history accumulates (or may never fire as expected). Update retention when applying a new config.
// SetConfig atomically replaces the configuration. The provided config must
// already be compiled and validated (use Parse). It also updates the tracker's
// retention so a hot-reload that widens a detector window/dwell does not prune
// older signals prematurely.
func (c *NodeHealer) SetConfig(cfg Config) {
	c.config.Store(&cfg)

Comment thread mgmt-agent/pkg/controller/nodehealer/config.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/controller.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/signals.go Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 17:19
@raelga
raelga force-pushed the raelga/aroslsre-1588-node-healer branch from 6e81bd9 to 55f59c8 Compare July 27, 2026 17:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Comment thread mgmt-agent/pkg/controller/nodehealer/controller.go Outdated
Comment thread mgmt-agent/deploy/templates/clusterrole.yaml
Comment thread mgmt-agent/pkg/controller/nodehealer/signals_test.go Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 17:27
@raelga
raelga force-pushed the raelga/aroslsre-1588-node-healer branch from 55f59c8 to c4f2c1d Compare July 27, 2026 17:31
@raelga

raelga commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c4f2c1d addressing the latest Copilot review round (all in-scope):

  • controller.go resyncAll: reset the wedgedNodes gauge to 0 on the disabled path so it can't linger stale after a hot-disable.
  • clusterrole.yaml: dropped the unused update verb on nodes (the labeler only uses Nodes().Patch); kept get/list/watch/patch. Helm fixture regenerated to match.
  • signals_test.go: renamed the unused dwellIgnored param to _.

Build, vet, and go test ./pkg/controller/nodehealer/... green. No behavior change beyond the gauge reset.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

mgmt-agent/deploy/templates/clusterrole.yaml:17

  • The node-healer uses a MergePatch to modify nodes (Nodes().Patch), so the ClusterRole does not appear to need the broader 'update' verb on nodes. Dropping it would reduce privileges (least-privilege) while still supporting the current implementation.
  verbs:
  - get
  - list
  - watch
  - patch
- apiGroups:

Comment thread mgmt-agent/pkg/controller/nodehealer/config.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/config.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/signals.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/metrics.go Outdated
Comment thread mgmt-agent/pkg/controller/nodehealer/labeler.go Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 17:34
@raelga

raelga commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot Both suppressed comments on this review were right, and they were the same bug in two places. Fixed.

The success timestamp is a Pod condition stamped by the kubelet's own clock, so skew can place it ahead of the controller's clock. Both comparisons on it were signed, so a future timestamp read as an arbitrarily fresh success.

I confirmed it before changing anything, by writing the test first against a replay of the real wedged node from the incident. With a success dated an hour ahead, Decide returned Healthy on a genuinely wedged node. That is the worst failure mode this controller has, since it fails silent: the node stays broken and nothing is labeled.

It is also worse than it looks from the Decide site alone, which is why your second comment matters. recordPodSuccess keeps the latest timestamp, so one skewed pod would win permanently and discard every genuine success that came after it, while observation() never pruned it because its age was negative. Detection would stay suppressed until the wall clock caught up with the skewed value.

Both sites now measure the distance in absolute terms. Skew inside the window is ordinary and still counts as a success, beyond the window the timestamp is not usable evidence in either direction and is dropped. Two regression tests cover it, one at the decision level and one on the pruning path.

Landed in fix(controllers): tolerate kubelet clock skew in the success signal.

No further action or commit needed from you, this is resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (2)

mgmt-agent/pkg/controller/nodehealth/labeler.go:91

  • Snapshot.Reason (the detector’s human-readable explanation) is populated in detectors.Decide but never surfaced: the node annotation currently records only snap.ReasonString() (evidence counts) and the detector name. This makes the Reason field effectively dead and leaves operators without the stable explanation on the node itself.

Consider including snap.Reason in the reason annotation when it’s available (while keeping the evidence snapshot), e.g. "<detector reason>; <evidence snapshot>".

	reason := snap.ReasonString()

mgmt-agent/pkg/controller/nodehealth/metrics.go:32

  • CONTRIBUTING.md requires before/after screenshots for metrics changes. This PR introduces a new nodehealth_* Prometheus subsystem, so please add before/after screenshots (e.g., a /metrics scrape captured in a browser or a Prometheus query view) to the PR description to meet the repo PR standards.
		&metrics.CounterOpts{
			Subsystem:      metricsSubsystem,
			Name:           "detections_total",
			Help:           "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",
			StabilityLevel: metrics.ALPHA,

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (2)

mgmt-agent/pkg/controller/nodehealth/metrics.go:33

  • This PR introduces new Prometheus metric series under the new nodehealth subsystem. Per CONTRIBUTING.md Pull Request Standards, metrics/observability changes require before/after screenshots in the PR description; the description currently includes a text scrape sample but not screenshots.
const metricsSubsystem = "nodehealth"

var (
	detectionsTotal = metrics.NewCounterVec(
		&metrics.CounterOpts{
			Subsystem:      metricsSubsystem,
			Name:           "detections_total",
			Help:           "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",
			StabilityLevel: metrics.ALPHA,
		},

mgmt-agent/pkg/controller/nodehealth/config.go:41

  • The Config.Enabled doc comment says the controller "records no state" when disabled, but the implementation explicitly records per-node success history while disabled (see controller.go:182-184 and recordPodSuccess). Please update the comment (or gate the recording) so operator-facing documentation matches real behavior.
	// Enabled is a hard off switch. When false the controller still runs its
	// informers but records no state, enqueues nothing, and takes no action.
	Enabled bool `json:"enabled"`

raelga added 8 commits August 1, 2026 14:11
Adds the pure decision core for detecting management-cluster nodes that are
Ready but cannot start pods, so node lifecycle never sees them as broken and
they silently swallow workloads.

Detection is a pure function of observed state: no I/O, no clock of its own, so
it is exhaustively table-testable. A detector declares which nodes it applies
to, evaluates Events and Pod state over a window, and fires only on a sustained
per-pod floor with no successful pod sandbox in the same window. That
zero-success rule is the load-bearing discriminator between a hard wedge and a
flap, since a flapping node still starts pods.

The first detector covers the SWIFT v2 VF teardown seen in the uksouth and
australiaeast incidents. Thresholds are hard-coded rather than configurable, so
the logic and its values live in one testable file.
…E-1588)

Drives the detector library from shared informers and reconciles level-driven
off informer state, so a missed Event cannot strand a node in the wrong state.
Events are correlated to their node by Source.Host and to their pod by UID.

The controller detects and labels only. Mitigation of a labeled node is left to
a separate controller, which keeps this one non-disruptive and safe to run in
production before anything acts on its labels.

Success is kept as a recorded per-node history rather than a point-in-time scan.
A pod that started and was then deleted leaves nothing behind, so reading the
absence of successful pods as zero would wedge a healthy but low-churn node. An
empty history is treated as unknown, never as a false zero, so a restart cannot
newly label a node until a full window has been observed.

Labeling is idempotent and re-reads the node before writing, so a stale informer
cache does not emit duplicate transitions or duplicate Events.
…SRE-1588)

Starts the controller alongside the existing ones and exposes its operational
switches, so it shares the process, the informer factory and the metrics
endpoint rather than shipping as a separate deployment.
Adds the RBAC, the deployment wiring and the ConfigMap that carries the
operational switches, so behaviour can be changed without a rebuild.

RBAC is deliberately narrow: read-only on Nodes, Pods and Events, patch on Nodes
for the label and annotations, and read on its own ConfigMap. No Secrets and no
guest-cluster access, which bounds the blast radius to node metadata.

The controller ships disabled. Enabling it is a separate, reversible step.
…1588)

The decision core is a pure function, so it is covered exhaustively by table:
each precondition, the floor and dwell boundaries, the zero-success rule, and
the cases that must stay indeterminate rather than guess.
…SRE-1588)

Covers the parts that are not pure: label and unlabel transitions including the
stale-cache races, the steady state making no API call at all, retention of a
label across a restart, the resync sweep, and the ConfigMap lifecycle
(hot-enable, hot-disable, delete, invalid YAML, missing key).
…ce (AROSLSRE-1588)

Replays state captured from a real wedged node during the uksouth incident, so
a future edit that would have missed the actual outage fails here.

The replay caught two false signals that the synthetic tests did not. Completed
Job and CronJob pods keep a failed sandbox condition long after they ran fine,
and the captured node carried enough of them to reach the floor on ordinary
turnover alone. Separately, pods started before the fault keep reporting a
healthy sandbox for as long as they run, so an unbounded success check would
have suppressed detection on the very node that was broken.

Identifiers from the capture are redacted, since no detector logic depends on
them. The failure strings and the node's label and readiness shape are verbatim.
@raelga

raelga commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot Both suppressed comments reviewed.

config.go Enabled doc comment. You are right, the comment did not match the code. recordPodSuccess is wired unconditionally into the pod add/update/delete handlers, only enqueue and resyncAll are gated on Enabled, so a disabled controller does write to the in-memory success map. Pushed the corrected comment, and the same correction in the design doc in Azure/ARO-HCP#6298:

// Enabled is a hard off switch. When false the controller enqueues
// nothing, reconciles nothing, and takes no action on any node. The pod
// informer handlers still write to the in-memory success map, but nothing
// reads it while disabled and it is discarded on the disabled->enabled
// transition, so a disabled controller carries no state forward.

The discard is what makes this safe: beginObserving resets observedSince and replaces successAt with an empty map on the disabled to enabled transition, so the controller re-earns a full observation window before the success signal can suppress anything. That behavior is pinned by TestHotEnableResetsObservation. I also tightened the OnConfigMap comment to say the map is discarded, not just that the warm-up restarts.

Metrics screenshots. The CONTRIBUTING rule is about changes that affect dashboards or metric visualizations. This adds a brand new nodehealth subsystem that has never been deployed, ships disabled by default, and has no dashboard or recording rule pointing at it yet, so there is no before state to capture and no live series to screenshot. The scrape sample in the description is the accurate representation of what the metrics emit. When these get wired into a dashboard, that change carries the screenshots.

No further action or commit needed from you, this is resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (1)

mgmt-agent/pkg/controller/nodehealth/consts.go:21

  • The ControllerName doc comment says it also feeds the event recorder component and log fields, but in the current implementation the event recorder component is hard-coded in cmd/options.go ("mgmt-agent-node-health") and controller logs use string literals. This comment is currently misleading; either wire the constant into those call sites or update the comment to match actual usage.
	// ControllerName is the single source of truth for this controller's name.
	// It feeds the workqueue name (which surfaces as a Prometheus label), the
	// event recorder component, and log fields, so those never drift.

@raelga

raelga commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot Correct, the comment overclaimed. ControllerName is referenced in exactly one place, the workqueue config in controller.go. The event recorder component in cmd/options.go is the literal mgmt-agent-node-health, which is the field-manager identity rather than the controller name, and the logs use their own literals.

I scoped the comment to what the constant actually does instead of wiring it into the recorder, because the recorder component is an externally visible Event source string and the field-manager identity it matches is what shows up in server-side apply conflicts. Collapsing the two onto one constant would tie those unrelated identities together for no gain.

// ControllerName is the single source of truth for this controller's name.
// It feeds the workqueue name, which surfaces as a Prometheus label on the
// workqueue metrics, so that label never drifts from the controller.

No further action or commit needed from you, this is resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (2)

mgmt-agent/pkg/controller/nodehealth/metrics.go:31

  • This PR introduces new nodehealth_* Prometheus series. CONTRIBUTING.md requires before/after screenshots for metrics changes; the PR description currently includes text output, but should also include screenshots (e.g., a captured /metrics scrape or a Grafana panel) to meet the repo PR standards.
	detectionsTotal = metrics.NewCounterVec(
		&metrics.CounterOpts{
			Subsystem:      metricsSubsystem,
			Name:           "detections_total",
			Help:           "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",

mgmt-agent/cmd/cmd.go:53

  • The help text says the node-health controller “watches only SWIFT-v2 nodes”, but the controller’s Pod/Event handlers enqueue nodes by Pod.Spec.NodeName and Event.Source.Host without checking for the SWIFT-v2 label. While non-SWIFT nodes end up NotApplicable (no label changes), they can still be reconciled when enabled, so the description is misleading. Please reword this to say it labels only SWIFT-v2 nodes (scoped by the AKS label) rather than claiming it watches only them.
   out-of-band consumer can act on it. It watches only SWIFT-v2 nodes and ships
   disabled by default.

@raelga

raelga commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot The cmd.go help text was wrong and is now fixed. enqueue gates only on Enabled, so a Pod or Event on any node enqueues that node. The SWIFT-v2 scoping is applied later, in Decide, which returns DecisionNotApplicable for a node without the AKS SWIFT-v2 label, and that path takes no action and touches no label. The help text now reads:

It labels only SWIFT-v2 nodes, scoped by the AKS node label, and ships
disabled by default.

I kept the scoping where it is rather than filtering at enqueue time. The applicability check runs against the live node from the informer cache, so a node that gains or loses the SWIFT-v2 label is re-evaluated on its next event, and a node that loses the label while carrying a stale wedged label still gets that label retired. Filtering at enqueue would make those stale labels unreachable, which is the specific failure the union-of-selectors sweep exists to prevent.

On the metrics screenshots: this adds a new nodehealth subsystem that has never been deployed, ships disabled by default, and has no dashboard or recording rule consuming it, so there is no before state and no live series to capture. The scrape sample in the description is the accurate representation of what is emitted. The change that wires these into a dashboard is the one that carries the screenshots.

No further action or commit needed from you, this is resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (2)

mgmt-agent/pkg/controller/nodehealth/config.go:59

  • Parse() uses yaml.Unmarshal, which silently ignores unknown fields. For an operational switch delivered via live-edited ConfigMap, that can turn typos (e.g. enabeld: true) into a silent no-op (controller stays disabled) without surfacing an error. Consider using yaml.UnmarshalStrict so unknown keys fail fast and are logged by OnConfigMap().
		if err := yaml.Unmarshal(data, &cfg); err != nil {

mgmt-agent/pkg/controller/nodehealth/controller.go:265

  • recordPodSuccess() updates the successAt map even when the controller is disabled. Since pruning only happens in observation() (which is only called from syncHandler when enabled), leaving the controller disabled (the default) can let successAt grow without bound over time and adds unnecessary per-Pod-event work while "off".
func (c *Controller) recordPodSuccess(obj interface{}) {
	if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
		obj = tombstone.Obj
	}
	pod, ok := obj.(*corev1.Pod)

@raelga

raelga commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot Both were real and both are fixed.

Strict config parsing. Parse now uses yaml.UnmarshalStrict. This config is live-edited on the cluster during an incident and the only field is the thing that turns the controller on, so a typo silently leaving it off was the worst possible failure mode. An unknown key is now a parse error, and OnConfigMap already logs a parse error and retains the previous config, so a bad edit is loud and non-destructive rather than quiet. Covered by two new TestParse cases, including enabeld: true.

Success map while disabled. recordPodSuccess now returns early when disabled. The map was not just growing, it was dead weight: beginObserving replaces it with an empty map both at startup after cache sync and on every disabled to enabled transition, so nothing recorded while off could ever be read. Dropping the write removes the per-Pod-event work and the growth in one go, and it makes the Enabled doc comment literally true.

TestHotEnableResetsObservation now asserts the opposite of what it did, that a disabled controller records nothing.

I checked both fixes are load-bearing by reverting them and re-running: without strict parsing the two unknown-key cases pass silently, and without the gate the disabled controller records 2026-07-29 11:58:00 +0000 UTC. Restored, and the full suite plus the Helm fixture test are green.

The design doc in Azure/ARO-HCP#6298 carries the matching update.

No further action or commit needed from you, this is resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (1)

mgmt-agent/pkg/controller/nodehealth/detectors/decide.go:109

  • MaxWindow() is called from Controller.observation() while holding obsMu (controller.go:286+), and currently recomputes the value by calling Detector.Evaluate(nil, nil, time.Time{}). For signatureDetector this allocates a map on every call (signature_detector.go:88), which adds unnecessary allocations and extends the time obsMu is held on every reconcile. Cache the computed maximum window once (detectors are hard-coded) and have MaxWindow() return the cached value.
func MaxWindow() time.Duration {
	var max time.Duration
	for _, d := range registry {
		if snap := d.Evaluate(nil, nil, time.Time{}); snap.Window > max {
			max = snap.Window

@raelga

raelga commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot Fixed, and I took the underlying cause rather than caching the result.

The real problem was that MaxWindow learned the window by evaluating each detector with nil Events, nil Pods and a zero time. That is a fake evaluation to read a constant, and it is what made the call allocate. The window is a fixed property of a detector, so Detector now declares it:

// Window is the detector's evaluation window. It is a fixed property of the
// detector, independent of any node, so callers that only need the window do
// not have to evaluate the detector to learn it.
Window() time.Duration

MaxWindow is now a sync.OnceValue over d.Window(), so the registry is walked once for the life of the process and the call under obsMu is a load. That removes the allocation, the evaluation, and the repeated work in one change instead of caching the result of something that should not have been computed that way.

Verified with go test -race -count=1 on the package, since MaxWindow is now read concurrently from reconciles.

No further action or commit needed from you, this is resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (1)

mgmt-agent/pkg/controller/nodehealth/labeler.go:73

  • The steady-state short-circuit only checks annotationDetector, but the doc comment says the labeler also refreshes a “stripped” detection record. If reason or observed-at annotations are missing (but detector is still present), this will incorrectly treat the node as steady-state and never self-heal the missing annotations.
	if node.Labels[labelKey] == labelValue && node.Annotations[annotationDetector] == detector {
		logger.V(4).Info("node already labeled wedged by this detector (cache)")
		return false, nil
	}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (1)

mgmt-agent/pkg/controller/nodehealth/controller.go:439

  • syncHandler() always calls gather() (listing all pods scheduled to the node and all pod Events emitted by the kubelet) before it determines that the node is actually a detector candidate. When the controller is enabled, enqueuePodObject/enqueueEventObject will enqueue nodes for all pods/events cluster-wide, so this can cause unnecessary per-node pod/event scans on nodes that will immediately resolve to DecisionNotApplicable.

Consider short-circuiting right after fetching the Node: if Decide(node, nil, nil, ...) returns DecisionNotApplicable, retire any stale label and return without calling gather(). This keeps the controller’s steady-state overhead aligned with the detector applicability rules.

	events, pods, err := c.gather(name)
	if err != nil {
		return err
	}

@raelga

raelga commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot Agreed on the cost, fixed, though not by evaluating the detectors with nil inputs.

The ownership question only reads the node, so it does not need a decision pass to answer. detectors now exports it:

// AnyApplies reports whether any detector owns this node. It reads only the
// node, so a caller can answer the ownership question before doing the work of
// gathering the node's Pods and Events. Decide applies the same gate, so a node
// this rejects can only ever produce DecisionNotApplicable.
func AnyApplies(node *corev1.Node) bool

Decide now uses that same function for its own ownership gate, so there is one implementation rather than two that could drift, and syncHandler calls it right after fetching the node. A node no detector owns retires its stale label and returns without touching the pod indexer or the event index.

I kept the DecisionNotApplicable arm of the switch, marked as unreachable for the same node, so the switch still covers Decide's full contract instead of depending on the caller having checked first. Both paths call one retireStaleLabel helper rather than duplicating the unlabel.

TestSyncHandlerRetiresLabelWhenNoDetectorApplies, TestSyncHandlerRetiresLabelOnNotReadyNonCandidate and TestSyncHandlerNotApplicablePreservesForeignLabelValue all pass unchanged, which is the point: the short-circuit is a cost change, not a behaviour change. New TestAnyApplies pins the agreement with Decide directly, including that a NotReady SWIFT node is still owned so its label is not silently retired by the fast path.

No further action or commit needed from you, this is resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Suppressed comments (1)

mgmt-agent/pkg/controller/nodehealth/metrics.go:55

  • This PR introduces new Prometheus metric series under the nodehealth subsystem. Per CONTRIBUTING.md Pull Request Standards ("Include Screenshots for Graph/UI/Metrics/Performance Changes"), metrics changes require before/after screenshots in the PR description. The description currently includes a text scrape sample, but not screenshots; please add screenshots (e.g., of a /metrics scrape or a Grafana panel) before approval.
const metricsSubsystem = "nodehealth"

var (
	detectionsTotal = metrics.NewCounterVec(
		&metrics.CounterOpts{
			Subsystem:      metricsSubsystem,
			Name:           "detections_total",
			Help:           "Number of times a detector caused a node to be marked wedged, counted when the node's detection record changes rather than on every re-evaluation.",
			StabilityLevel: metrics.ALPHA,
		},
		[]string{"detector"},
	)

	labelActionsTotal = metrics.NewCounterVec(
		&metrics.CounterOpts{
			Subsystem:      metricsSubsystem,
			Name:           "label_actions_total",
			Help:           "Number of label/unlabel actions, by action and result.",
			StabilityLevel: metrics.ALPHA,
		},
		[]string{"action", "result"},
	)

	wedgedNodes = metrics.NewGauge(
		&metrics.GaugeOpts{
			Subsystem:      metricsSubsystem,
			Name:           "wedged_nodes",
			Help:           "Number of nodes currently carrying the wedged health label, as observed by the node-health controller. Reported as 0 while the controller is disabled.",
			StabilityLevel: metrics.ALPHA,
		},
	)
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants